Skip to content

fix(cli): validate captured image and font downloads - #3809

Merged
jrusso1020 merged 2 commits into
mainfrom
fix/security-capture-downloads
Sep 9, 2026
Merged

fix(cli): validate captured image and font downloads#3809
jrusso1020 merged 2 commits into
mainfrom
fix/security-capture-downloads

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Capture downloads currently trust remote filename suffixes and persist arbitrary response bytes. URLs such as favicon.svg:payload, og.jpg::$DATA, and font.woff2:ads can become NTFS stream names; CON.woff2 selects a reserved Windows basename. This change validates accepted image/font content and chooses canonical extensions before writing, while preserving accepted bytes and ordinary font names.

  • Shared response reader caps actual bytes at 2 MiB per icon, 20 MiB per image, and 10 MiB per font, with one 100 MiB aggregate budget shared by the website capture image/font passes. It cancels oversized responses and does not trust Content-Length.
  • Icons, promoted favicons, OG images, and catalog images use decoded raster formats, checked ICO containers, or passive SVG. SVG retains theme CSS/custom properties and local references without reserialization; active elements, external loads, CSS escapes, and SMIL elements are rejected. Raster decoding is limited to 40 million pixels. Embedded SVG raster images require matching MIME/decoded content and share 10 MiB byte and 40 million pixel limits.
  • Fonts require supported container headers plus readable fontkit metrics/character maps. Unsafe/reserved names become deterministic hashed names; valid existing basenames remain stable when unique. Deterministic suffixes prevent case-insensitive/canonical-extension collisions from overwriting distinct font bytes. Captured CSS points at the validated local file.
  • css-tree is now a direct CLI dependency to inspect CSS syntax instead of applying an ad hoc replacement sanitizer.

Alert map: #886 icon acceptance/write; #887 publication of the same accepted icon; #877 OG image acceptance/write; #878 font acceptance/write. These remain intentional network-to-file operations, so no automatic alert closure or dismissal is claimed. Please review each surviving CodeQL flow and its appropriate disposition independently. Lottie archive/preview controls (#363/#364) are the next separate batch.

Validation: full workspace build; CLI typecheck; all 268 capture tests; five new end-to-end security cases fail against the original downloader, and six reviewer regression cases fail against the first PR head. Tests cover canonical icon promotion, invalid OG payloads, ADS/reserved font names, byte preservation, bounded/chunked response cancellation, font headers, real PNG/ICO/font fixtures, and SVG theme/local-reference preservation and active-content rejection. Signed commit hooks include lint/format/typecheck and Fallow. Fallow has one inherited complexity finding and two test clone groups (warnings); no new complexity violations. Please explicitly assess the remaining clone warnings and compatibility limits above before approval.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The byte-derived extensions, Win32-safe font naming, stream cancellation, and parser-based SVG policy are the right boundaries. I verified the three focused validator suites locally (23/23), including the exact bytes-at-limit and CSS escape cases.

Blocker — embedded raster data bypasses the image validator. packages/cli/src/capture/captureImageValidation.ts:11-15 accepts any base64-shaped data:image/png|jpeg|gif|webp string without decoding or checking its claimed type/pixel bounds, and the SVG arm returns at :132 after that lexical check. Concrete probe: <image href="data:image/png;base64,QUFBQUFBQUFB"/> is accepted as .svg even though those bytes are not a PNG. More importantly, a small compressed raster with huge decoded dimensions bypasses the 40M-pixel ceiling and is then interpreted automatically by the unchanged downstream SVG renderers at packages/cli/src/capture/contactSheet.ts:321 and packages/cli/src/capture/contentExtractor.ts:572. Validate every embedded raster against its declared MIME with a bounded decoded-size/pixel check (and cap total embedded bytes), or reject data-image embeds; add wrong-magic and decompression-bomb controls.

Important — the 75 MiB limit is per response, not per capture. packages/cli/src/capture/assetDownloader.ts:707 applies the video-sized ceiling to every icon/image/font. Five image fetches run concurrently at :398-430, so the chunk arrays plus Buffer.concat can transiently approach 750 MiB for one batch; the 30-font loop at :536-591 can persist 2.25 GiB during the 120s capture budget. That is finite but still a practical remote memory/disk exhaustion path. Before using boundedness as evidence for #886/#887/#877/#878, use type-appropriate limits and a shared aggregate byte budget (exact thresholds can come from real capture fixtures).

Important compatibility edge — canonicalization can collapse two fonts. captureFontValidation.ts:49-57 returns only stem + canonicalExtension and the caller at assetDownloader.ts:588-592 has no used-name set. site.ttf served as WOFF2 and site.woff2 both become site.woff2; likewise two directories/query variants with the same basename. The later write replaces the earlier bytes and both CSS URLs are rewritten to the same file. Preserve the ordinary name when unique, but add deterministic collision handling and a two-distinct-font regression.

Fallow: nonblocking. The only complexity finding is inherited deriveAssetName (blame predates this PR). Both clone groups are test-only fixture/setup repetition; neither duplicates a production decision, so I would not expand scope for them.

I am not approving dispositions yet: #886/#887/#877/#878 still represent intentional persistence, but the SVG nested-payload and aggregate-resource controls have to close before “validated content” is sufficient evidence for scoped false-positive/accepted-capability classifications. CI and the JavaScript CodeQL scan were still running at review time.

— Magi

Verdict: REQUEST CHANGES
Reasoning: The top-level type/path validation is strong, but embedded raster bytes bypass it and the shared response ceiling is still multiplicative enough to exhaust capture resources; font canonicalization also introduces a deterministic overwrite case.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code blockers from review 5153057215 are resolved at 889c2b6d31b1.

  • captureImageValidation.ts:137-185 now collects every embedded raster reference, validates decoded bytes against the declared MIME, counts animated pages into shared 10 MiB/40M-pixel budgets, and decodes accepted content. I re-ran the former wrong-magic probe (now rejected) and an 84-page 1280×720 WebP embed (aggregate 77.4M pixels, now rejected).
  • readBoundedResponse.ts:1-42 owns the 100 MiB capture budget and debits actual chunks synchronously across concurrent readers; assetDownloader.ts:202,420-425,490-495,605-610 applies the 2/20/10 MiB family ceilings. The same budget instance is created once at capture/index.ts:612 and passed through both font and image passes.
  • captureFontValidation.ts:61-71 reserves names case-insensitively and deterministically suffixes collisions; the end-to-end two-real-font test proves both files and both CSS rewrites survive.

Local exact-head verification: four focused files, 65/65 tests pass. The six reviewer regressions genuinely fail the prior head by construction: wrong/mismatched embedded MIME, aggregate embedded pixels, concurrent/shared byte budgets, and unit/end-to-end font collisions. Fallow remains nonblocking: inherited deriveAssetName complexity; no production clone group. The PR body now matches the 2/20/10/100 MiB limits and 268-test result.

CodeQL rule js/http-to-file-access asks for an intent audit rather than a prescribed sanitizer. The exact PR merge-ref JavaScript analysis 1747215971 reports 0 results, and the PR-ref open-alert set is empty. Per original IDs, no manual dismissal is warranted:

  • #886: fixed — 2 MiB icon, decoded/canonical type, passive SVG boundary, safe generated stem.
  • #887: fixed/dependent — copies only #886-validated bytes to the canonical fixed favicon name.
  • #877: fixed — 20 MiB + aggregate budget, decoded image/passive SVG, fixed stem + canonical extension.
  • #878: fixed — 10 MiB + aggregate budget, container/fontkit/expanded-size validation, Windows-safe collision-free name.

Those should close automatically as fixed after merge; do not dismiss them pre-merge. If any remains after the main scan, the same evidence supports a scoped false-positive disposition, but only after confirming its source/sink is unchanged.

I am not lifting the old CHANGES_REQUESTED yet because the required Tests on windows-latest aggregate is red: its first exact-head run was externally canceled while render was at frame 351/360 and the Studio lanes were still progressing, so this is not a code failure, but the replacement run 34342425236 is still in progress. The main required Test aggregator is also pending. Re-request once those native gates settle; source is ready for approval.

— Magi

Verdict: COMMENT
Reasoning: All substantive review findings are fixed and the exact-head security evidence is clean, but required Windows/aggregate checks have not yet passed, so approval must wait for the native gates.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All substantive findings from review 5153057215 remain resolved at unchanged head 889c2b6d31b1, as documented with exact source and local evidence in review 5153323778.

The external report now matches GitHub: replacement Windows run 34342425236 is fully successful, CI 34342425320 is successful including the required Test aggregator, all required checks pass, the head has no failing check runs, and CodeQL 34341826280 / JS analysis 1747215971 is successful with zero PR-ref results and no open PR alerts. #886/#887/#877/#878 should close automatically as fixed on the main scan; no dismissal is warranted before merge.

Fallow assessment is unchanged and nonblocking: inherited deriveAssetName complexity and test-only repetition, with no duplicated production decision.

— Magi

Verdict: APPROVE
Reasoning: The security, resource-bound, and font-fidelity blockers are fixed at this exact head, and every native required gate plus CodeQL is now green.

@jrusso1020
jrusso1020 merged commit 1aa5b9e into main Sep 9, 2026
87 of 91 checks passed
@jrusso1020
jrusso1020 deleted the fix/security-capture-downloads branch September 9, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants